Skip to content

Go 标准库必熟:从一件具体工作找到入口 ​

拿到一行配置,要把 limit=25 变成键和整数;收到 HTTP 请求,要限制大小、解析 JSON,再记一条不会泄露令牌的日志。这些工作会连续经过好几个标准库包。熟悉它们,靠的不是背下所有导出函数,而是知道每一步该交给谁,以及交接时哪些约定不能弄错。

这篇文章覆盖知识地图列出的 17 个包。先按工作找入口,再看短代码、实际输出和容易误用的地方。每个包都有可运行例子,完整源码与测试在文末。验证环境是 Go 1.27.2、Linux/amd64,日期为 2026-10-13;没有沿用其他系统上的基准数字,也不把本地小例子当作生产性能结论。

先认路:这件事由哪个包负责 ​

要完成的工作包先记住的入口
把值排成文本,打印或写入输出流fmtPrintf、Sprintf、Fprintf、Errorf
查找、切分、清理、拼接字符串stringsContains、Cut、Fields、Join、Builder
文本与数字、布尔值互转strconvAtoi、Itoa、ParseInt、ParseBool、FormatFloat
处理字节切片或内存缓冲bytesCut、TrimSpace、Clone、Buffer
表示时刻、时长,解析时间,等待或定时timeNow、Since、Parse、NewTimer、NewTicker
在数据来源与目的地之间搬运字节ioReader、Writer、Copy、ReadFull、LimitReader
给读写加缓冲,按行或 token 读取bufioNewReader、NewWriter、NewScanner
操作文件、目录、环境变量和进程osOpen、OpenFile、ReadFile、LookupEnv、Exit
Go 值与 JSON 互转encoding/jsonMarshal、Unmarshal、Encoder、Decoder
接收或发起 HTTP 请求net/httpHandler、ServeMux、Server、Client
给一段工作传递取消、截止时间和请求值contextWithCancel、WithTimeout、WithDeadline
保护共享数据、等待任务、只做一次syncMutex、WaitGroup、Once
构造、识别和组合错误errorsNew、Is、As、Join、AsType
输出带级别和字段的日志log/slogNew、NewJSONHandler、With、LogAttrs
读懂旧代码里的排序与二分查找sortSlice、SliceStable、Search
对切片排序、查找、增删、复制slicesSortFunc、BinarySearch、Delete、Clone
复制、比较 map,取得键值迭代器mapsClone、Copy、Equal、Keys、All

下面每节的 API 与行为都对应同版本官方包文档。表里有两组容易选错:手里已经是 []byte 时,优先看 bytes;手里是 string 时,看 strings。新写的普通切片算法通常从 slices 开始,sort 则仍有通用二分条件搜索等入口,并不是一个应该全部删除的旧包。[1][2][3][4][5]

一、处理文本:先分清格式化、切分和转换 ​

fmt:名字决定结果去哪,动词决定怎么写 ​

Print 系列写标准输出,Fprint 系列接收一个 io.Writer,Sprint 系列返回字符串。后缀 f 表示使用格式串,ln 表示按对应规则加空格并在末尾换行。fmt.Errorf 则返回错误值。[1:1]

常用动词作用典型用途
%v、%+v默认形式;结构体加字段名临时检查一个值
%#v、%TGo 语法表示;类型名检查类型与具体表示
%d、%x十进制;十六进制数字或字节内容
%s、%q文本;带引号并转义普通文本与需要看清换行的文本
%w在 Errorf 中包装错误保留错误链供 errors.Is/As 检查
go
p := struct {
    Name  string
    Count int
}{"book", 2}
fmt.Printf("%v | %+v | %T\n", p, p, p)
fmt.Printf("%q | %04d | %x\n", "a\nb", 7, "Go")
format := "%d"
fmt.Println(fmt.Sprintf(format, "seven"))

实际输出:

text
{book 2} | {Name:book Count:2} | struct { Name string; Count int }
"a\nb" | 0007 | 476f
%!d(string=seven)

最后一行没有 panic。格式动词与参数类型不匹配时,fmt 把诊断信息写进结果;如果这段文本进了日志或响应,错误也一起传了出去。静态可见的调用 fmt.Printf("%d", "seven") 会被 go vet 报出,文末留有这个反例。上面使用变量格式串,是为了单独观察运行时输出。[1:2][6]

自定义 String() string 可以影响格式化。实现时不要再用 %v、%s 打印接收者自己,否则可能再次调用同一个 String,形成递归。本例先转回普通数值,再转换文本:[1:3]

go
type Celsius float64
func (c Celsius) String() string {
    return strconv.FormatFloat(float64(c), 'f', 1, 64) + "°C"
}
fmt.Println(Celsius(21.5))

实际输出 21.5°C。若需求只是“整数变成十进制文本”,strconv.Itoa 比格式串更直接;是否值得为了速度替换,仍应测真实调用点,本文没有做性能倍数判断。

strings:去后缀和删除字符集合是两件事 ​

按动作记入口,比背函数列表有用:查找用 Contains、HasPrefix、HasSuffix、Index;切分用 Cut、Split、Fields;拼接用 Join 或 Builder;清理边界用 TrimSpace、TrimPrefix、TrimSuffix;替换用 ReplaceAll;忽略 Unicode 大小写比较用 EqualFold。[2:1]

go
fmt.Println(strings.TrimRight("log.go", ".go"),
    strings.TrimSuffix("log.go", ".go"))
key, value, found := strings.Cut("limit=25", "=")
fmt.Println(key, value, found)
fmt.Println(len(strings.Split("", ",")), len(strings.Fields("")))

实际输出:

text
l log
limit 25 true
1 0

TrimRight 的第二个参数是字符集合。它从右向左删除 .、g、o,删完扩展名后发现 log 的末尾仍是 g、o,于是只剩 l。真正去掉一个完整后缀,应使用 TrimSuffix。TrimLeft、Trim 也采用字符集合语义。[2:2]

Cut 只在第一个分隔符处切开,并用布尔值表示是否找到;这比先 Split 再假设存在下标 1 更适合解析一对键值。Fields 按连续 Unicode 空白切分,不产生首尾的空项;Split("", ",") 则保留原来的空字符串,得到一个元素。两种行为对应不同任务,不能混用“长度为零”判断它们的输入是否为空。[2:3]

多段文本逐步构建时可以用 Builder:

go
var b strings.Builder
b.Grow(8)
b.WriteString("go")
b.WriteByte('-')
b.WriteString("team")
fmt.Println(b.String())

实际输出 go-team。Grow(8) 保证至少还能写入 8 字节而无需另一次分配,不改变当前长度;使用过的 Builder 不应按值复制。字符串不可变,但不能由此简单推导“每个 + 都必定分配一次”:编译器可能优化表达式。循环拼接是否值得换写法,要结合长度、次数和测量结果。[2:4][7]

strconv:返回了一个数,不代表解析成功 ​

方向常用入口要自己决定的参数
十进制文本 ↔ intAtoi、Itoaint 位宽随平台而定
文本 → 有符号/无符号整数ParseInt、ParseUint进制与目标取值位宽
整数 → 文本FormatInt、FormatUint进制
文本 ↔ 浮点数ParseFloat、FormatFloat位宽、显示格式与精度
文本 ↔ 布尔值ParseBool、FormatBoolParseBool 只接受文档列出的写法
文本 → 可见的带引号表示Quote用于 Go 风格引号与转义,不是通用 HTML 转义

这些入口的返回类型与错误约定见同版本文档。[8]

go
_, err := strconv.Atoi(" 25")
fmt.Println("syntax", errors.Is(err, strconv.ErrSyntax))
n, err := strconv.ParseInt("256", 10, 8)
fmt.Println(n, errors.Is(err, strconv.ErrRange))
fmt.Println(string(rune(66)), strconv.Itoa(66))
fmt.Println(strconv.FormatFloat(1.25, 'f', 2, 64), strconv.Quote("a\nb"))

实际输出:

text
syntax true
127 true
B 66
1.25 "a\nb"

Atoi 不替调用者清理空白,读取人工输入时可以先明确地 TrimSpace。ParseInt 的 bitSize=8 指定可接受范围,返回类型仍为 int64;256 超出有符号 8 位范围,于是返回上界 127 和范围错误。忽略错误就会把一个失败结果当成真实配置。[8:1]

整数转换为 string 的语言含义是生成对应 Unicode 码点的 UTF-8 文本,所以 66 变成 B。需要数字字符 "66" 时使用 Itoa 或 FormatInt。这两种转换都合法,错的是把它们当成同一种操作。[7:1]

bytes:缓冲区交出的切片可能仍属于它 ​

bytes.Contains、Cut、Split、TrimSpace 等入口与 strings 类似,输入换成 []byte。bytes.NewReader 适合把已有字节作为只读输入,bytes.Buffer 适合在内存里接住输出;Buffer 的零值可用,同时实现 Reader 和 Writer。[3:1]

go
var buf bytes.Buffer
buf.WriteString("draft")
alias := buf.Bytes()
saved := bytes.Clone(alias)
buf.Reset()
buf.WriteString("FINAL")
fmt.Println(string(alias), string(saved))

本次输出:

text
FINAL draft

Bytes() 没有复制,它返回当前未读部分的视图。文档只保证这份视图在下一次修改缓冲区前有效。本例重用了底层容量,所以旧切片读到了新内容;不要把“下一次总会变成哪段文本”当成保证。bytes.Clone 做出独立字节副本,才保留了 draft。[3:2]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    B["bytes.Buffer"] --> A["共享底层字节区<br/>重写后为 FINAL"]
    V["alias := buf.Bytes()"] --> A
    C["saved := bytes.Clone(alias)"] --> D["独立字节区<br/>仍为 draft"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    B["bytes.Buffer"] --> A["共享底层字节区<br/>重写后为 FINAL"]
    V["alias := buf.Bytes()"] --> A
    C["saved := bytes.Clone(alias)"] --> D["独立字节区<br/>仍为 draft"]

这条所有权规则在 Scanner.Bytes、网络缓冲和对象池代码里会反复出现:拿到切片,不等于取得可以长期保存的独立副本。

二、时间:时刻、时长、时区分别是什么 ​

time.Time 表示时刻,time.Duration 表示以纳秒计的时长,time.Location 表示时区规则。Now 取当前时刻,Since(start) 算经过多久,Add、Sub 做时刻与时长运算,Before、After、Equal 比较时刻,Format 与 Parse 负责文本。等待一次可看 Sleep、After、NewTimer,周期事件可看 NewTicker。[9]

布局写的是参考时间,不是 YYYY ​

go
t := time.Date(2026, 10, 13, 9, 30, 0, 0, time.UTC)
fmt.Println(t.Format("YYYY-MM-DD"), t.Format(time.RFC3339))

实际输出:

text
YYYY-MM-DD 2026-10-13T09:30:00Z

Go 的布局是参考时间 2006-01-02 15:04:05 按所需形式写出的样子,YYYY 不是年份占位符。手写容易混淆时,用 time.RFC3339、DateTime、DateOnly 等常量。对外交换一个时刻,通常还需要时区或 UTC 偏移,光有年月日和时分秒可能无法唯一确定它。[9:1]

同一时刻可以有不同的内部表示 ​

go
east := time.FixedZone("UTC+8", 8*60*60)
same := t.In(east)
fmt.Println(t == same, t.Equal(same))
a, _ := time.Parse(time.DateTime, "2026-10-13 09:30:00")
b, _ := time.ParseInLocation(time.DateTime, "2026-10-13 09:30:00", east)
fmt.Println(a.Sub(b))

实际输出:

text
false true
8h0m0s

== 还比较时区位置及单调时钟信息,Equal 比较表示的时刻。Parse 遇到没有时区的文本按 UTC 处理;ParseInLocation 则使用传入的位置,所以同一段文本可相差八小时。示例为了不依赖系统时区数据库用了固定偏移;现实中的地区时间应按需要用 LoadLocation("Asia/Shanghai") 等 IANA 位置,涉及夏令时的地区不能用固定偏移代替完整规则。[9:2]

短例省略了已知常量的解析错误检查,附录实际代码保留了它。对于外部时间文本,不能省略错误处理。

go
var d time.Duration = 3
fmt.Println(d, 3*time.Second)
now := time.Now()
fmt.Println(now == now.Round(0), now.Equal(now.Round(0)))

本次输出:

text
3ns 3s
false true

裸数字 3 是三纳秒。把变量秒数转成时长,应写 time.Duration(n)*time.Second。Now 通常同时带墙上时钟和单调时钟读数;Since、Sub 在双方都有单调读数时利用它计算经过时间,避免受墙上时间校准干扰。Round(0) 会去掉单调信息。本次第二行反映了这点,但反序列化、解析所得 Time 原本可能就没有单调读数,不能期待所有 Time 都打印相同对照。[9:3]

定时器文章必须带版本 ​

旧资料里“未停止的定时器不能回收”和“timer channel 有一个缓存槽”的说法,不能直接套到当前 Go。Go 1.23 引入了可回收的未引用定时器与同步 timer channel,当时还受模块版本和兼容开关影响;Go 1.27 已删除 asynctimerchan 兼容设置。[10]

go
timer := time.NewTimer(time.Hour)
fmt.Println("timer", cap(timer.C), timer.Stop())

本次输出:

text
timer 0 true

附录还用 go 1.22 模块运行同一个程序,容量仍为 0。更重要的是,旧配置 GODEBUG=asynctimerchan=1 在本次 Go 1.27.2 环境里会让程序启动失败;把它写成 //go:debug 也会编译失败。它不是恢复旧行为的现行办法。启动时报错的关键行是:[10:1][11]

text
fatal error: removed GODEBUG "asynctimerchan" set to old value "1" in environment

垃圾回收能收走不可达对象,不等于业务已经停止。一段还活着的 goroutine 仍可能保留 ticker 并持续工作;不再需要周期事件时仍应停止并结束对应循环。Ticker.Stop 不关闭 C,不要靠 for range ticker.C 自动退出。timer 的 Reset、Stop 以及 AfterFunc 的语义也不同,改旧代码前应读所用版本的具体方法文档。[9:4]

三、输入输出:把 Reader 与 Writer 接起来 ​

io:一次 Read 不保证读满 ​

io.Reader 只要求一个 Read(p []byte) (n int, err error) 方法;io.Writer 对应 Write。文件、网络连接、字节缓冲和字符串读取器由此能互相组合。io.Copy(dst,src) 不需要知道输入来自硬盘还是内存,只需要两边遵守接口约定。[12]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    S["来源<br/>文件 / 网络 / strings.Reader"] --> L["io.LimitReader<br/>限制可读字节数"]
    L --> B["bufio.Reader<br/>缓冲读取"]
    B --> C["io.Copy<br/>持续读写,直到结束或错误"]
    C --> M["io.MultiWriter<br/>依次转发写入"]
    M --> F["目标一:文件"]
    M --> O["目标二:bytes.Buffer"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    S["来源<br/>文件 / 网络 / strings.Reader"] --> L["io.LimitReader<br/>限制可读字节数"]
    L --> B["bufio.Reader<br/>缓冲读取"]
    B --> C["io.Copy<br/>持续读写,直到结束或错误"]
    C --> M["io.MultiWriter<br/>依次转发写入"]
    M --> F["目标一:文件"]
    M --> O["目标二:bytes.Buffer"]
要求入口与其他入口的区别
持续搬运直到结束io.Copy不把全部内容留在调用方内存里
一次读完并保留io.ReadAll输入可能很大,调用者负责上限
必须凑齐固定长度io.ReadFull不足时返回 EOF 或 UnexpectedEOF
最多暴露 N 字节io.LimitReader到上限就像结束,自己不报告“源数据超长”
一份写入交给多个输出io.MultiWriter依次写,出错后停止,不是原子事务
读取时顺便复制一份io.TeeReader只有实际被读到的数据才会被复制
接收并丢弃输出io.Discard适合不需要保留内容的数据流

上述行为来自 io 的接口与函数契约。[12:1]

go
r := iotest.OneByteReader(strings.NewReader("ready"))
p := make([]byte, 5)
n, err := r.Read(p)
fmt.Println(n, err)
n, err = io.ReadFull(r, p[1:])
fmt.Println(n, err, string(p))

实际输出:

text
1 <nil>
4 <nil> ready

缓冲区有五字节,不代表这次 Reader 必须给足五字节。第一次短读没有错误,后面 ReadFull 才继续凑满剩余四字节。网络代码若把“一次读取”等同于“一个完整消息”,就会在拆包时出错。[12:2]

另一个合法结果是同时返回数据和 EOF。附录的 eofReader 最后一次交出 ok 时也返回 EOF,实测得到:

go
n, err := (&eofReader{}).Read(p)
fmt.Println(string(p[:n]), err == io.EOF)
text
ok true

所以读循环应先处理 p[:n],再判断错误,否则会丢掉最后一批数据。Reader 要用 io.EOF 本身表示普通结束,不应包装它;更复杂的失败可以按需要保留错误链。Writer 的约定不对称:短写时必须同时返回非 nil 错误。自己实现 Writer 时不能返回“少写且 nil”;附录另有测试确认 io.Copy 能把这种违约报告为 io.ErrShortWrite。[12:3]

限制长度时,要区分“截断”与“拒绝超长” ​

ReadAll(LimitReader(r,4)) 可以保护读取上限,但无法仅凭结果区分输入原本只有四字节,还是十万字节被截成四字节。若业务要求拒绝超长,可以多读一字节,再判断长度:

go
// 本片段假定 max 非负,且 max+1 不溢出;完整函数在附录检查这两个条件。
data, err := io.ReadAll(io.LimitReader(r, max+1))
if err != nil {
    return nil, err
}
if int64(len(data)) > max {
    return nil, ErrTooLarge
}
return data, nil

把 "12345" 交给最大四字节的 ReadBounded,实际得到 errors.Is(err,ErrTooLarge) == true。HTTP 服务端请求体还可以用 http.MaxBytesReader,它以专门的 MaxBytesError 报告超限,文末测试验证了这一行为。限制字节数仍不等于限制时间;慢输入还需要相应超时。[12:4][13]

多路输出也能直接组合:

go
var a, b bytes.Buffer
n, err := io.Copy(io.MultiWriter(&a, &b), strings.NewReader("go"))
fmt.Println(n, err, a.String(), b.String())

实际输出 2 <nil> go go。如果后一个 Writer 出错,前一个可能已经写入成功。涉及多份持久状态时,不能把 MultiWriter 当成一致性事务。[12:5]

bufio:缓冲能提高批量程度,也会推迟可见结果 ​

bufio.NewReader 包住 Reader,可以使用 ReadString('\n')、ReadBytes 等按分隔符读;NewWriter 包住 Writer,先积攒再交给下层;NewScanner 用 Scan 按行或自定义 token 迭代。Reader 和 Writer 的默认缓冲大小在当前实现中为 4096 字节,它与 Scanner 的 token 上限不是同一个概念。[14]

go
var out bytes.Buffer
w := bufio.NewWriter(&out)
_, err := w.WriteString("ready")
if err != nil { panic(err) }
fmt.Println("before", out.Len())
if err := w.Flush(); err != nil { panic(err) }
fmt.Println("after", out.Len())

实际输出:

text
before 0
after 5

成功写进 bufio 的缓冲,不代表已经成功写到底层。结束时要 Flush,而且要处理它的错误;文件 Close 也不会自动替另一个 bufio.Writer 刷新。[14:1]

Scanner 的使用习惯则应该包括循环后的 Err:

go
sc := bufio.NewScanner(strings.NewReader(input))
var lengths []int
for sc.Scan() {
    lengths = append(lengths, len(sc.Text()))
}
fmt.Println(lengths, sc.Err())

本次输入是 first、70,000 个 x、last 三行,默认设置输出:

text
[5] bufio.Scanner: token too long

扫描器停止后,第三行也没有被处理。默认 MaxScanTokenSize 是 64×1024,但允许的实际 token 长度还会受分隔符等因素影响,不能据此承诺“任何 64 KiB 的行都能完整读下”。在第一次 Scan 前设置 sc.Buffer(make([]byte,0,4096),1<<20),同样输入得到:[14:2]

text
[5 70000 4] <nil>

Scanner.Bytes() 的内容可能在下一次扫描时被覆盖,保存时使用副本;Text() 返回字符串副本。需要处理非常大的行、精确控制读取和恢复过程时,考虑 Reader,而不是无限调大 Scanner 上限。[14:3]

os:打开方式、关闭错误和退出位置都重要 ​

工作常用入口最容易漏掉的区别
一次读写整个文件ReadFile、WriteFile文件可能很大;WriteFile 不是原子替换
打开已有文件读取Open是只读打开
创建或清空文件Create已存在的内容会被截断
指定追加等方式OpenFile明确 O_APPEND、O_CREATE、读写标志
管理目录和条目Stat、ReadDir、MkdirAll、Remove、Rename文件类型、权限与平台行为仍需检查
读取环境变量Getenv、LookupEnv后者区分未设置与空字符串
参数、标准流、退出Args、Stdin/Stdout/Stderr、ExitExit 不执行 defer

这些都是 os 包提供的入口;真实路径与系统错误不能靠字符串猜测,优先使用结构化错误判断。[15]

在临时目录里先写 old,再追加 +new:

go
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0600)
if err != nil { panic(err) }
_, writeErr := f.WriteString("+new")
closeErr := f.Close()
if writeErr != nil { panic(writeErr) }
if closeErr != nil { panic(closeErr) }
data, err := os.ReadFile(path)
if err != nil { panic(err) }
fmt.Println(string(data))

实际输出 old+new。随后 os.Create(path) 并关闭,同一个文件的长度变成 0;用 os.Open 打开后尝试写入则返回错误。附录只操作自己的临时文件,没有依赖修改系统文件来演示这些行为。[15:1]

文件读取失败时,errors.Is(err,fs.ErrNotExist) 可以穿过 *os.PathError 判断“不存在”;新代码不必优先使用旧的 os.IsNotExist。环境变量的实测对照是:

text
empty "" true
unset "" false

两行来自 LookupEnv:设成空串仍然存在,删除后才是 false。Getenv 在这两种情况下都只返回空串。[15:2]

还有一个经常导致清理失效的短例:

go
func main() {
    defer fmt.Println("cleanup")
    fmt.Println("working")
    os.Exit(3)
}

本次构建成二进制运行,只输出 working,实际退出码为 3。os.Exit 立即终止进程,不运行 defer;log.Fatal 最后也会调用 Exit。需要清理时,可以把工作放进返回 error 的 run,等它正常返回、内部 defer 执行后,再由 main 决定退出码。[15:3][16]

WriteFile 中途失败可能留下部分内容。需要原子替换或持久化保证时,应另行设计临时文件、同步、重命名及平台边界,不能只凭函数名推断。处理外部提供的路径时,还可以了解 Go 1.24 加入的 os.Root:它限制目录树内的文件访问,但不等于进程沙箱,也不代替业务授权。本文未对跨平台文件替换或 Root 攻击场景做测试。[15:4]

四、JSON:先约定类型,再决定严格到什么程度 ​

encoding/json 有两组入口:Marshal/Unmarshal 处理内存中的字节切片;Encoder/Decoder 直接接 io.Writer/io.Reader。前一组适合已经完整拿到的数据,后一组适合与流组合,但“流式入口”不保证一整个大对象都不占内存。结构体标签决定字段名及忽略规则。[17]

编码:零值、空值和未导出字段分别处理 ​

go
type User struct {
    ID      int64     `json:"id"`
    Name    string    `json:"name"`
    Tags    []string  `json:"tags"`
    Created time.Time `json:"created,omitempty"`
    Updated time.Time `json:"updated,omitzero"`
    email   string
}
u := User{ID: 1, Name: "<reader>", email: "private@example.invalid"}
b, err := json.Marshal(u)
if err != nil { panic(err) }
fmt.Println(string(b))

实际输出保留了反斜杠转义:

json
{"id":1,"name":"\u003creader\u003e","tags":null,"created":"0001-01-01T00:00:00Z"}

这一个对象里有四种不同原因,不能统称“空字段会省略”:

字段或内容结果原因
小写 email不输出未导出字段不参与普通结构体编码
nil 切片 Tagsnullv1 默认把 nil 切片编码为 null
Created 加 omitempty仍输出零时间v1 的 empty 定义不包括普通结构体值
Updated 加 omitzero被省略按零值或类型的 IsZero 方法判断
<reader>\u003creader\u003ev1 默认做 HTML 敏感字符转义

omitzero 从 Go 1.24 加入。需要禁用 HTML 转义时,可以创建 Encoder 并调用 SetEscapeHTML(false);它不会变成 HTML 安全模板引擎,是否能把结果嵌到页面里仍要按使用位置判断。另外,[]byte 在这个包中默认编码为 base64 字符串,不是整数数组。[17:1][18]

解码到 any 时,大整数可能已经变了 ​

go
raw := `{"id":9007199254740993,"name":"Ada"}`
var dynamic map[string]any
if err := json.Unmarshal([]byte(raw), &dynamic); err != nil { panic(err) }
fmt.Printf("%T %.0f\n", dynamic["id"], dynamic["id"])
var typed User
if err := json.Unmarshal([]byte(raw), &typed); err != nil { panic(err) }
fmt.Println(typed.ID)
dec := json.NewDecoder(strings.NewReader(raw))
dec.UseNumber()
if err := dec.Decode(&dynamic); err != nil { panic(err) }
fmt.Printf("%T %v\n", dynamic["id"], dynamic["id"])

实际输出:

text
float64 9007199254740992
9007199254740993
json.Number 9007199254740993

默认解码到接口时,数字成为 float64。它不能精确表示所有 int64 整数,示例的最后一位在这个步骤已经丢失;以后再转成 int64 也补不回来。业务字段有固定含义时,直接解码进 int64 字段;确实需要动态结构时,用 UseNumber 保留数字文本,再调用 Number.Int64 等方法并检查错误。[17:2]

Decode 成功一次,不代表请求体只有一个 JSON ​

结构体默认忽略未知字段;DisallowUnknownFields 可以改变这一点。单次 Decode 又只消费一个 JSON 值,因此下面的第二次解码仍会成功:[17:3]

go
dec := json.NewDecoder(strings.NewReader(`{"name":"Ada"} {"name":"Lin"}`))
var first User
if err := dec.Decode(&first); err != nil { panic(err) }
var extra any
fmt.Println("second JSON", dec.Decode(&extra) == nil)

实际输出:

text
second JSON true

如果接口约定“一次请求只有一个对象”,需要在第一次解码后再确认 EOF。附录的 DecodeOne 做了两件事:禁止结构体未知字段;第二次 Decode 只接受 EOF,其余值或语法错误都报错。四个测试分别验证正常对象、两个对象、未知字段和尾部垃圾。这个函数仍应配合读取大小上限与超时使用,不能独自承担全部请求防护。

Go 1.27 的 json/v2 已经可以正常导入 ​

Go 1.25 时,encoding/json/v2 还是实验;Go 1.27 正式提供它,并让原来的 encoding/json 使用新实现,同时保持 v1 的编解码行为,错误文字可能变化。v1 API 继续支持,不要求现有程序立即迁移。遇到兼容问题时,1.27 的发布说明提供了构建时 GOEXPERIMENT=nojsonv2 的临时回退方式;是否保留到以后的版本,不能由此假定。[19]

新实现与新默认行为要分开。 导入 encoding/json 仍使用 v1 契约;显式导入 encoding/json/v2 才采用 v2 默认选项。下面是本次已验证的差异:[17:4][20]

同一个输入encoding/json 默认encoding/json/v2 默认
nil []stringnull[]
字符串 <reader>转义 <、>不做这项 HTML 转义
字段标签 id,输入键 ID可以按不区分大小写规则匹配不匹配,字段保持原值
对象内重复名字允许按 v1 规则处理拒绝重复名字

同一个 u 交给 v2,本次得到:

go
b, err := jsonv2.Marshal(u) // import jsonv2 "encoding/json/v2"
if err != nil { panic(err) }
fmt.Println(string(b))
json
{"id":1,"name":"<reader>","tags":[],"created":"0001-01-01T00:00:00Z"}

分别把 {"ID":9} 解码到两个零值 User,输出 case 9 0;v2 解码 {"x":1,"x":2} 返回错误。未知字段在 v2 默认下也不是一概报错,不要把“默认更严格”理解成“所有额外信息都自动拒绝”。迁移还涉及 omitempty 定义等其他变化,改 import 前应对照包文档的迁移表,并为已有数据做兼容测试。[20:1]

五、HTTP、取消、同步、错误:一次请求怎样收尾 ​

这四个包值得各写一篇深入文章,这里先建立能实际使用的最小连接。

net/http:网络调用成功,不代表业务状态成功 ​

服务端常用 Handler、HandlerFunc、NewServeMux 和 Server;客户端常用 Client、NewRequestWithContext 与 Do。简单演示可用 http.Get,需要明确超时或复用配置时应自己持有 Client。Client 和 Transport 可以复用,并且支持多个 goroutine 并发使用。[13:1]

下面在进程内启动一个本地测试服务,不依赖外网:

go
mux := http.NewServeMux()
mux.HandleFunc("GET /greet/{name}", func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    fmt.Fprint(w, "hello "+r.PathValue("name"))
})
server := httptest.NewServer(mux)
defer server.Close()
client := &http.Client{Timeout: time.Second}
defer client.CloseIdleConnections()

方法与路径通配符模式、PathValue 是 Go 1.22 起的 ServeMux 能力,旧项目不能只看这段语法就假定同样工作。下面接着发出请求。must 是示例里遇到错误就 panic 的辅助函数,完整定义在附录;生产代码应把错误交给自己的处理路径。读取失败时也先关闭 Body,再处理错误:[13:2][21]

go
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet,
    server.URL+"/greet/Ada", nil)
must(err)
resp, err := client.Do(req)
must(err)
body, readErr := ReadBounded(resp.Body, 1024)
closeErr := resp.Body.Close()
must(readErr)
must(closeErr)
fmt.Println(resp.StatusCode, string(body))

第一行输出来自这次请求;后两行分别来自访问 /missing 和读取默认 Client 的 Timeout,完整操作在附录:

text
200 hello Ada
missing 404 true
default timeout 0s

第二行请求不存在的路径,状态为 404,但 Client.Get 的 error 是 nil。HTTP 错误状态仍是一份成功收到的 HTTP 响应;调用者要同时检查传输错误与 StatusCode。第三行来自 http.DefaultClient.Timeout:它没有整体超时。即使底层某些阶段另有超时,也不能把它当作整个请求已有明确截止时间。[13:3]

响应 Body 由调用者负责关闭。若希望复用持久连接,通常应读到 EOF 再关闭;对未知或异常巨大响应,又不能为了连接复用无上限地读取。所以示例用 ReadBounded 限制正常响应,超限就接受该连接可能无法复用的结果。服务端接收请求体、读取请求头、空闲连接等也有各自限制;生产服务应使用按场景配置的 http.Server,示例的本地 httptest 不代表这些配置已经完成。[13:4]

context:传递取消信号,不代替 goroutine 退出 ​

Background 常作顶层起点。WithCancel 创建可取消的子 context,WithTimeout 用相对时长设置截止时间,WithDeadline 使用绝对时刻。通常把 ctx context.Context 放在函数的第一个参数,用同一请求链传给下游。WithValue 适合请求范围的关联数据,不适合塞入所有可选参数。[22]

go
ctx, cancel := context.WithCancel(context.Background())
cancel()
<-ctx.Done()
fmt.Println(errors.Is(ctx.Err(), context.Canceled))
past, release := context.WithDeadline(context.Background(), time.Unix(0, 0))
defer release()
<-past.Done()
fmt.Println(errors.Is(past.Err(), context.DeadlineExceeded))

实际输出:

text
true
true

Done 关闭后可以接收,Err 说明取消原因。它不会强制终止一段忽略信号的代码,使用者要检查 Done/Err,或者调用本身支持 context 的 API。创建带取消函数的 context 后,应在合适的结束位置调用该函数;defer cancel() 是常见写法,即使预计超时最终会发生,也能更早释放相关资源。取消函数本身不会等待所有工作退出,需要结束确认时还要配合 WaitGroup 等机制。[22:1]

sync:等任务完成和保护数据是两项职责 ​

工具负责什么不负责什么
Mutex、RWMutex保护共享状态的访问不替你定义哪些字段必须一起保护
WaitGroup等待一组任务结束不收集业务错误,不自动给共享变量加锁
Once、OnceValue执行一次或计算一次结果不会因为普通失败就自动重试
Cond围绕受锁保护的条件等待和唤醒不保存通用消息队列
Pool临时对象复用不保证对象一直保留,不是资源库存
Map针对特定并发使用模式的 map不自动保证跨多个键的业务不变量

这些同步类型有各自的约束。用过之后不能随意复制包含它们的值;多数含锁结构体应由指针传递,并让保护范围清楚。[23]

go
var mu sync.Mutex
var wg sync.WaitGroup
total := 0
for range 3 {
    wg.Go(func() {
        mu.Lock()
        defer mu.Unlock()
        total++
    })
}
wg.Wait()
fmt.Println("total", total)

var once sync.Once
count := 0
for range 3 {
    once.Do(func() { count++ })
}
fmt.Println("once", count)

实际输出:

text
total 3
once 1

Mutex 保护递增,WaitGroup 让读取结果发生在任务完成后,两者不能互相替代。WaitGroup.Go 在 Go 1.25 加入,把计数与启动 goroutine 合在一个入口里;文档要求传入的函数不能 panic。旧写法仍可用 Add、Done,但不要把初次 Add 放进刚启动的 goroutine,让外面的 Wait 有机会先看到计数为零。[23:1]

本例在 -race 下通过,只能说明这次实际执行的路径没有报告数据竞争。更改共享状态的访问后仍要重新检查,race 也不替业务逻辑证明“结果一定符合预期”。[24]

errors:沿包装关系判断,不去比较错误文字 ​

errors.New 适合构造独立错误,fmt.Errorf("...: %w",err) 增加上下文但保留原错误关系,Is 检查是否匹配目标,As 找特定类型,Join 合并多个错误。Go 1.26 加入的 AsType[T] 返回指定类型和布尔值,减少 As 的目标变量写法。[25]

go
wrapped := fmt.Errorf("load settings: %w", fs.ErrNotExist)
fmt.Println(wrapped == fs.ErrNotExist, errors.Is(wrapped, fs.ErrNotExist))
pathErr := &os.PathError{
    Op: "open", Path: "settings.json", Err: fs.ErrPermission,
}
joined := errors.Join(wrapped, pathErr)
var target *os.PathError
fmt.Println(errors.As(joined, &target), target.Op, target.Path)
typed, ok := errors.AsType[*os.PathError](joined)
fmt.Println(ok, typed.Op, errors.Is(joined, fs.ErrPermission))

实际输出:

text
false true
true open settings.json
true open true

包装后的值不再与原哨兵错误直接相等,但 Is 能沿错误关系找到它。Join 让错误关系可以分叉,不总是一条单链;Is、As 会按文档规则遍历。这里第二、三行的类型一定存在,所以短例可以直接读字段;一般代码必须在 As 或 AsType 返回 true 后再使用结果。[25:1]

这不意味着一切 == 都错误。Reader 协议要求直接返回 io.EOF,直接判断 EOF 就有明确契约;两个专门共享的哨兵值也能直接比较。判断业务错误时,先确认生产者的协议,再选择 Is/As,避免依赖可能随版本或上下文改变的错误文字。[12:6][25:2]

六、日志:结构化以后,字段仍需要审查 ​

slog.NewJSONHandler 和 NewTextHandler 分别提供 JSON 与文本格式;slog.New 生成 Logger;logger.With 附加每条记录共有的字段;Debug、Info、Warn、Error 表示级别。HandlerOptions.Level 控制最低级别,动态调整可使用 LevelVar。想让全局 slog.Info 也使用自己的 Logger,调用 SetDefault。[26]

附录的 newLogger 只为了让输出可复现,用 ReplaceAttr 删除时间字段;真实服务通常应保留时间。然后记录两条 Info 和一条 Debug:

go
logger := newLogger(os.Stdout).With("service", "demo")
logger.Info("accepted", slog.Int("count", 2),
    slog.Any("token", Secret("sample-secret")))
logger.Debug("hidden by default")
args := []any{"orphan"}
logger.Info("bad fields", args...)

实际输出两行:

json
{"level":"INFO","msg":"accepted","service":"demo","count":2,"token":"[REDACTED]"}
{"level":"INFO","msg":"bad fields","service":"demo","!BADKEY":"orphan"}

Debug 没出现,因为默认最低级别是 Info;第二条 Info 的键值参数落单,slog 用 !BADKEY 留下诊断,而不是让程序报错。显式使用 slog.String、Int 等 Attr,以及需要时使用 LogAttrs(ctx,level,msg,attrs...),能让字段关系更清楚。常量参数的明显错配也可能被 vet 检查出来。[26:1][6:1]

Secret 的实现是:

go
type Secret string
func (Secret) LogValue() slog.Value {
    return slog.StringValue("[REDACTED]")
}

这使通过 slog 记录该类型时得到脱敏值。但把它先转回 string 再记录,就绕过了这个方法;直接记录请求头或原始配置也不会自动安全。日志设计仍应先列出必要字段,避免密码、会话令牌和多余个人信息进入记录。附录测试检查了示例令牌不出现在这条日志中,不代表所有调用点已经完成敏感数据审查。[26:2]

七、排序和集合:看返回值,也看底层数据是否共享 ​

sort:Search 返回的是边界位置 ​

sort.Slice 用返回 bool 的 less(i,j) 比较切片元素;SliceStable 保持相等元素的原先次序;Ints、Strings 是基本类型入口。Search(n,f) 则寻找从 false 转为 true 的第一个位置,要求谓词在搜索区间满足这种单调分界。它不局限于“找到某个元素”。[4:1]

go
xs := []int{1, 4, 8}
fmt.Println(sort.SearchInts(xs, 6), sort.SearchInts(xs, 10))
fmt.Println(slices.BinarySearch(xs, 6))

实际输出:

text
2 3
2 false

6 应该放在下标 2 前,10 应放在末尾,也就是下标 3。找不到时不是 -1;直接拿 SearchInts 返回值索引可能读到别的数,甚至越界。BinarySearch 把“插入位置”和“是否找到”分开返回,更容易使用;这些数值搜索都以输入已排序为前提。[4:2][5:1]

slices:函数返回的新切片必须接住 ​

常用入口可以按四组记:排序 Sort/SortFunc/SortStableFunc;查找 Contains/Index/BinarySearch;改动 Insert/Delete/Compact/Reverse;复制、比较和连接 Clone/Equal/Concat。Compact 只去掉相邻重复项,并不自动消除任意位置的重复值。[5:2]

go
all := []int{1, 2, 3, 4, 5}
after := slices.Delete(all, 1, 3)
fmt.Println(after, all)

实际输出:

text
[1 4 5] [1 4 5 0 0]

Delete 在原底层数组上移动元素,并清零不再属于结果的尾部;它返回的是新的切片长度,旧变量 all 的长度不会自动改变。通常应写 s = slices.Delete(s,i,j),之后避免继续把旧视图当成原始内容。Insert 还可能因容量不足换一块底层数组,更需要接住返回值。[5:3]

结构体按多个键排序,可以把比较关系直接写出来:

go
type person struct { Name string; Age int }
people := []person{{"Lin", 30}, {"Ada", 30}, {"Bo", 20}}
slices.SortFunc(people, func(a, b person) int {
    return cmp.Or(cmp.Compare(a.Age, b.Age), cmp.Compare(a.Name, b.Name))
})
fmt.Println(people)

实际输出:

text
[{Bo 20} {Ada 30} {Lin 30}]

比较函数返回负数、零、正数,分别表示在前、等价、在后;这与 sort.Slice 的 bool 不同。cmp.Compare 避免用 a-b 比较整数时可能溢出的写法,cmp.Or 选第一个非零比较结果。若排序中相等项需要保持原顺序,用稳定版本;普通 SortFunc 不承诺稳定。[5:4][27]

maps:浅拷贝与顺序是两个独立问题 ​

maps.Clone 复制 map,Copy(dst,src) 把源键值覆盖到目标,Equal 比较键和值,DeleteFunc 按条件删除;Keys、Values、All 返回迭代器。Copy 不会清掉目标里源 map 没有的键,也要求目标能够接收写入。[28]

go
m := map[string][]int{"b": {2}, "a": {1}}
fmt.Println(slices.Sorted(maps.Keys(m)))
cloned := maps.Clone(m)
cloned["a"][0] = 7
fmt.Println(m["a"], cloned["a"])

实际输出:

text
[a b]
[7] [7]

第一行显式排序,所以输出稳定;maps.Keys 自身不保证顺序,而且返回的是迭代器,不是切片。第二行修改了副本 map 中的切片,原 map 也看到 7,因为 Clone 只复制键值,切片值仍共享原来的底层数组。slices.Clone 也采用元素赋值语义,深层可变对象需要按用途继续复制。[5:5][28:1]

还有两条类型边界:maps.Equal 要求值可比较,map[string][]int 不能直接传给它,可以用 EqualFunc(m1,m2,slices.Equal[[]int]) 一类匹配签名的比较函数;这些辅助函数也没有给普通 map 添加并发同步。需要并发读写时,仍要按原来的数据所有权或锁设计保护。[28:2][29]

八、随用随查:go doc 和 go vet 各解决一半问题 ​

go doc strings.Cut 直接查看当前工具链的函数契约,go doc -all strings 展开整个包。网页版可以固定版本,例如本文引用的 @go1.27.2。查错时要问清楚“语言规则”“当前包契约”“当前实现”是哪一层,不要拿一条旧实现观察覆盖新的 API。[30]

go vet 能发现一些合法但可疑的代码,不能证明程序正确。本次把下面六类问题集中到独立反例:

反例本次 vet 的发现
%d 打印字符串动词与参数类型不匹配
复制 Mutexassignment copies lock value
丢掉 WithTimeout 返回的 cancel可能保留不必要的 context 资源
string(n) 想得到十进制数字实际产生一个 rune 的文本
slog 键值参数落单missing a final value
布局 2006-02-01 想表示年月日常见月份、日期顺序错误

完整输出中的诊断顺序可能变化,工具并没有读懂全部业务意图。例如你可能确实需要“年-日-月”,遇到 vet 提示仍需结合意图判断。go test 默认运行一部分 vet 检查,不等于已经执行完整 go vet ./...;这次正向代码两种检查都跑了,反例则单独预期失败。[6:2][30:1]

标准库会继续演进。本文涉及的版本线索中,slog/slices/maps 是 Go 1.21,函数迭代器及其集合入口是 1.23,omitzero 和 os.Root 是 1.24,WaitGroup.Go 是 1.25,errors.AsType 是 1.26,正式 json/v2 与旧 timer 兼容开关删除是 1.27。看见新写法时,除了更新工具链,还应检查项目的最低支持版本。[10:2][18:1][19:1][23:2][25:3][31]

本次验证记录与没做的事 ​

六个正向测试分别覆盖:有限读取边界、Scanner 超长 token、敏感日志示例、Copy 检测短写、HTTP 请求体大小限制、恰好一个 JSON 值。主程序覆盖全部 17 个包,HTTP 请求只发往测试进程创建的本地服务器,文件操作只使用临时目录。Go 1.27.2 下 go test -count=1、go test -race、go run -race .、go vet 全部通过。

另外实测了三个故意失败的场景:vet 诊断示例、进程直接 Exit 跳过 defer、已删除 timer 兼容设置的失败;旧语言版本模块里的 timer 容量也已单独运行。附录保留完整源码和命令,便于按原样复现。

没有做性能基准、真实网络故障、跨平台文件持久性或生产并发负载测试。无错误的小例子只能证明这次输入与路径符合预期;学习这些包更重要的是把契约带回自己的场景:谁拥有数据,谁关闭资源,如何判断完成,失败会留下一份什么状态。

参考资料 ​

附录:完整代码、测试和反例 ​

把以下文件放进同一项目目录。示例使用的令牌与邮箱均为虚构演示值。main.go 中的 must 只用于让小实验遇到意外错误时立即失败,不是生产服务的错误处理建议。普通包不需要外部模块。

text
stdliblab/
├── go.mod
├── main.go
├── main_test.go
└── testdata/
    ├── vetbad/main.go
    ├── exitdemo/main.go
    ├── oldtimer/{go.mod,main.go}
    └── removeddebug/{go.mod,main.go}

验证命令:

bash
go version                    # 本次:go1.27.2 linux/amd64
go env GOOS GOARCH CGO_ENABLED GOEXPERIMENT
go run .
go run -race .
go test -count=1 -v ./...
go test -count=1 -race ./...
go vet ./...
# 以下是有意的反例,分别执行,不要用 set -e 让第一个失败吞掉后续检查。
go vet ./testdata/vetbad       # 预期退出 1
go build -o exit-demo ./testdata/exitdemo
./exit-demo                   # 只打印 working,预期退出 3
(cd testdata/oldtimer && go run .) # 输出 0
(cd testdata/oldtimer && GODEBUG=asynctimerchan=1 go run .) # 预期启动失败
(cd testdata/removeddebug && go build .) # 预期编译失败

下面是全部文件,正文用到的辅助函数、测试替身与失败案例都在这里。

go.mod ​

go.mod
module example.com/stdliblab

go 1.27

main.go ​

代码块 · 406 行 · 点击收起 / 展开
go
package main

import (
	"bufio"
	"bytes"
	"cmp"
	"context"
	"encoding/json"
	jsonv2 "encoding/json/v2"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"log/slog"
	"maps"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"slices"
	"sort"
	"strconv"
	"strings"
	"sync"
	"testing/iotest"
	"time"
)

type Celsius float64

func (c Celsius) String() string { return strconv.FormatFloat(float64(c), 'f', 1, 64) + "°C" }
func must(err error) {
	if err != nil {
		panic(err)
	}
}
func heading(s string) { fmt.Println("==", s, "==") }

func textDemo() {
	heading("fmt")
	p := struct {
		Name  string
		Count int
	}{"book", 2}
	fmt.Printf("%v | %+v | %T\n", p, p, p)
	fmt.Printf("%q | %04d | %x\n", "a\nb", 7, "Go")
	format := "%d" // Runtime format mismatch; the static counterexample is in testdata.
	fmt.Println(fmt.Sprintf(format, "seven"))
	fmt.Println(Celsius(21.5))

	heading("strings")
	fmt.Println(strings.TrimRight("log.go", ".go"), strings.TrimSuffix("log.go", ".go"))
	key, value, found := strings.Cut("limit=25", "=")
	fmt.Println(key, value, found)
	fmt.Println(len(strings.Split("", ",")), len(strings.Fields("")))
	var b strings.Builder
	b.Grow(8)
	b.WriteString("go")
	b.WriteByte('-')
	b.WriteString("team")
	fmt.Println(b.String())

	heading("strconv")
	_, err := strconv.Atoi(" 25")
	fmt.Println("syntax", errors.Is(err, strconv.ErrSyntax))
	n, err := strconv.ParseInt("256", 10, 8)
	fmt.Println(n, errors.Is(err, strconv.ErrRange))
	fmt.Println(string(rune(66)), strconv.Itoa(66))
	fmt.Println(strconv.FormatFloat(1.25, 'f', 2, 64), strconv.Quote("a\nb"))

	heading("bytes")
	var buf bytes.Buffer
	buf.WriteString("draft")
	alias := buf.Bytes()
	saved := bytes.Clone(alias)
	buf.Reset()
	buf.WriteString("FINAL")
	fmt.Println(string(alias), string(saved))
}

func timeDemo() {
	heading("time")
	t := time.Date(2026, 10, 13, 9, 30, 0, 0, time.UTC)
	fmt.Println(t.Format("YYYY-MM-DD"), t.Format(time.RFC3339))
	east := time.FixedZone("UTC+8", 8*60*60)
	same := t.In(east)
	fmt.Println(t == same, t.Equal(same))
	a, err := time.Parse(time.DateTime, "2026-10-13 09:30:00")
	must(err)
	b, err := time.ParseInLocation(time.DateTime, "2026-10-13 09:30:00", east)
	must(err)
	fmt.Println(a.Sub(b))
	var d time.Duration = 3
	fmt.Println(d, 3*time.Second)
	now := time.Now()
	fmt.Println(now == now.Round(0), now.Equal(now.Round(0)))
	timer := time.NewTimer(time.Hour)
	fmt.Println("timer", cap(timer.C), timer.Stop())
}

type eofReader struct{ offset int }

func (r *eofReader) Read(p []byte) (int, error) {
	n := copy(p, "ok"[r.offset:])
	r.offset += n
	if r.offset == 2 {
		return n, io.EOF
	}
	return n, nil
}

var ErrTooLarge = errors.New("input exceeds limit")

func ReadBounded(r io.Reader, max int64) ([]byte, error) {
	if max < 0 || max == int64(^uint64(0)>>1) {
		return nil, errors.New("invalid limit")
	}
	data, err := io.ReadAll(io.LimitReader(r, max+1))
	if err != nil {
		return nil, err
	}
	if int64(len(data)) > max {
		return nil, ErrTooLarge
	}
	return data, nil
}
func scanLengths(input string, max int) ([]int, error) {
	sc := bufio.NewScanner(strings.NewReader(input))
	if max > 0 {
		sc.Buffer(make([]byte, 0, 4096), max)
	}
	var lengths []int
	for sc.Scan() {
		lengths = append(lengths, len(sc.Text()))
	}
	return lengths, sc.Err()
}
func ioDemo() {
	heading("io")
	r := iotest.OneByteReader(strings.NewReader("ready"))
	p := make([]byte, 5)
	n, err := r.Read(p)
	fmt.Println(n, err)
	n, err = io.ReadFull(r, p[1:])
	fmt.Println(n, err, string(p))
	n, err = (&eofReader{}).Read(p)
	fmt.Println(string(p[:n]), err == io.EOF)
	_, err = ReadBounded(strings.NewReader("12345"), 4)
	fmt.Println("too large", errors.Is(err, ErrTooLarge))
	var copy1, copy2 bytes.Buffer
	n64, err := io.Copy(io.MultiWriter(&copy1, &copy2), strings.NewReader("go"))
	fmt.Println(n64, err, copy1.String(), copy2.String())

	heading("bufio")
	var out bytes.Buffer
	w := bufio.NewWriter(&out)
	_, err = w.WriteString("ready")
	must(err)
	fmt.Println("before", out.Len())
	must(w.Flush())
	fmt.Println("after", out.Len())
	input := "first\n" + strings.Repeat("x", 70_000) + "\nlast\n"
	lengths, err := scanLengths(input, 0)
	fmt.Println(lengths, err)
	lengths, err = scanLengths(input, 1<<20)
	fmt.Println(lengths, err)
}

func osDemo() {
	heading("os")
	dir, err := os.MkdirTemp("", "stdliblab-")
	must(err)
	defer os.RemoveAll(dir)
	path := filepath.Join(dir, "note.txt")
	must(os.WriteFile(path, []byte("old"), 0600))
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0600)
	must(err)
	_, err = f.WriteString("+new")
	closeErr := f.Close()
	must(err)
	must(closeErr)
	data, err := os.ReadFile(path)
	must(err)
	fmt.Println(string(data))
	f, err = os.Create(path)
	must(err)
	must(f.Close())
	data, err = os.ReadFile(path)
	must(err)
	fmt.Println("after Create", len(data))
	f, err = os.Open(path)
	must(err)
	_, err = f.WriteString("x")
	must(f.Close())
	fmt.Println("read-only write failed", err != nil)
	_, err = os.ReadFile(filepath.Join(dir, "missing"))
	fmt.Println("not found", errors.Is(err, fs.ErrNotExist))
	const key = "STDLIBLAB_LOCAL_EMPTY"
	previous, existed := os.LookupEnv(key)
	defer func() {
		if existed {
			must(os.Setenv(key, previous))
		} else {
			must(os.Unsetenv(key))
		}
	}()
	must(os.Setenv(key, ""))
	v, ok := os.LookupEnv(key)
	fmt.Printf("empty %q %v\n", v, ok)
	must(os.Unsetenv(key))
	v, ok = os.LookupEnv(key)
	fmt.Printf("unset %q %v\n", v, ok)
}

type User struct {
	ID      int64     `json:"id"`
	Name    string    `json:"name"`
	Tags    []string  `json:"tags"`
	Created time.Time `json:"created,omitempty"`
	Updated time.Time `json:"updated,omitzero"`
	email   string
}

// DecodeOne expects exactly one JSON value, with no unknown struct fields.
func DecodeOne(r io.Reader, dst any) error {
	dec := json.NewDecoder(r)
	dec.DisallowUnknownFields()
	if err := dec.Decode(dst); err != nil {
		return err
	}
	var extra any
	if err := dec.Decode(&extra); err != io.EOF {
		if err == nil {
			return errors.New("multiple JSON values")
		}
		return err
	}
	return nil
}

func jsonDemo() {
	heading("json")
	u := User{ID: 1, Name: "<reader>", email: "private@example.invalid"}
	b, err := json.Marshal(u)
	must(err)
	fmt.Println(string(b))
	raw := `{"id":9007199254740993,"name":"Ada"}`
	var dynamic map[string]any
	must(json.Unmarshal([]byte(raw), &dynamic))
	fmt.Printf("%T %.0f\n", dynamic["id"], dynamic["id"])
	var typed User
	must(json.Unmarshal([]byte(raw), &typed))
	fmt.Println(typed.ID)
	dec := json.NewDecoder(strings.NewReader(raw))
	dec.UseNumber()
	must(dec.Decode(&dynamic))
	fmt.Printf("%T %v\n", dynamic["id"], dynamic["id"])
	dec = json.NewDecoder(strings.NewReader(`{"name":"Ada","extra":1}`))
	dec.DisallowUnknownFields()
	fmt.Println("unknown rejected", dec.Decode(&typed) != nil)
	dec = json.NewDecoder(strings.NewReader(`{"name":"Ada"} {"name":"Lin"}`))
	must(dec.Decode(&typed))
	var extra any
	fmt.Println("second JSON", dec.Decode(&extra) == nil)
	b, err = jsonv2.Marshal(u)
	must(err)
	fmt.Println(string(b))
	var v1, v2 User
	must(json.Unmarshal([]byte(`{"ID":9}`), &v1))
	must(jsonv2.Unmarshal([]byte(`{"ID":9}`), &v2))
	fmt.Println("case", v1.ID, v2.ID)
	var duplicate map[string]int
	fmt.Println("v2 duplicate rejected", jsonv2.Unmarshal([]byte(`{"x":1,"x":2}`), &duplicate) != nil)
}

func httpDemo() {
	heading("net/http")
	mux := http.NewServeMux()
	mux.HandleFunc("GET /greet/{name}", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		fmt.Fprint(w, "hello "+r.PathValue("name"))
	})
	server := httptest.NewServer(mux)
	defer server.Close()
	client := &http.Client{Timeout: time.Second}
	defer client.CloseIdleConnections()
	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/greet/Ada", nil)
	must(err)
	resp, err := client.Do(req)
	must(err)
	body, readErr := ReadBounded(resp.Body, 1024)
	closeErr := resp.Body.Close()
	must(readErr)
	must(closeErr)
	fmt.Println(resp.StatusCode, string(body))
	resp, err = client.Get(server.URL + "/missing")
	must(err)
	_, readErr = io.Copy(io.Discard, resp.Body)
	closeErr = resp.Body.Close()
	must(readErr)
	must(closeErr)
	fmt.Println("missing", resp.StatusCode, err == nil)
	fmt.Println("default timeout", http.DefaultClient.Timeout)
}

func contextDemo() {
	heading("context")
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	<-ctx.Done()
	fmt.Println(errors.Is(ctx.Err(), context.Canceled))
	past, release := context.WithDeadline(context.Background(), time.Unix(0, 0))
	defer release()
	<-past.Done()
	fmt.Println(errors.Is(past.Err(), context.DeadlineExceeded))
}

func syncDemo() {
	heading("sync")
	var mu sync.Mutex
	var wg sync.WaitGroup
	total := 0
	for range 3 {
		wg.Go(func() { mu.Lock(); defer mu.Unlock(); total++ })
	}
	wg.Wait()
	fmt.Println("total", total)
	var once sync.Once
	count := 0
	for range 3 {
		once.Do(func() { count++ })
	}
	fmt.Println("once", count)
}

func errorsDemo() {
	heading("errors")
	wrapped := fmt.Errorf("load settings: %w", fs.ErrNotExist)
	fmt.Println(wrapped == fs.ErrNotExist, errors.Is(wrapped, fs.ErrNotExist))
	pathErr := &os.PathError{Op: "open", Path: "settings.json", Err: fs.ErrPermission}
	joined := errors.Join(wrapped, pathErr)
	var target *os.PathError
	fmt.Println(errors.As(joined, &target), target.Op, target.Path)
	typed, ok := errors.AsType[*os.PathError](joined)
	fmt.Println(ok, typed.Op, errors.Is(joined, fs.ErrPermission))
}

type Secret string

func (Secret) LogValue() slog.Value { return slog.StringValue("[REDACTED]") }
func newLogger(w io.Writer) *slog.Logger {
	return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			if len(groups) == 0 && a.Key == slog.TimeKey {
				return slog.Attr{}
			}
			return a
		},
	}))
}
func slogDemo() {
	heading("slog")
	logger := newLogger(os.Stdout).With("service", "demo")
	logger.Info("accepted", slog.Int("count", 2), slog.Any("token", Secret("sample-secret")))
	logger.Debug("hidden by default")
	args := []any{"orphan"}
	logger.Info("bad fields", args...)
}

func collectionsDemo() {
	heading("sort")
	xs := []int{1, 4, 8}
	fmt.Println(sort.SearchInts(xs, 6), sort.SearchInts(xs, 10))
	fmt.Println(slices.BinarySearch(xs, 6))
	heading("slices")
	all := []int{1, 2, 3, 4, 5}
	after := slices.Delete(all, 1, 3)
	fmt.Println(after, all)
	type person struct {
		Name string
		Age  int
	}
	people := []person{{"Lin", 30}, {"Ada", 30}, {"Bo", 20}}
	slices.SortFunc(people, func(a, b person) int { return cmp.Or(cmp.Compare(a.Age, b.Age), cmp.Compare(a.Name, b.Name)) })
	fmt.Println(people)
	heading("maps")
	m := map[string][]int{"b": {2}, "a": {1}}
	fmt.Println(slices.Sorted(maps.Keys(m)))
	cloned := maps.Clone(m)
	cloned["a"][0] = 7
	fmt.Println(m["a"], cloned["a"])
}

func main() {
	textDemo()
	timeDemo()
	ioDemo()
	osDemo()
	jsonDemo()
	httpDemo()
	contextDemo()
	syncDemo()
	errorsDemo()
	slogDemo()
	collectionsDemo()
}

main_test.go ​

代码块 · 87 行 · 点击收起 / 展开
go
package main

import (
	"bytes"
	"errors"
	"io"
	"net/http"
	"net/http/httptest"
	"slices"
	"strings"
	"testing"
)

func TestReadBounded(t *testing.T) {
	for _, tc := range []struct {
		input    string
		max      int64
		tooLarge bool
	}{{"", 0, false}, {"four", 4, false}, {"five!", 4, true}} {
		got, err := ReadBounded(strings.NewReader(tc.input), tc.max)
		if tc.tooLarge {
			if !errors.Is(err, ErrTooLarge) {
				t.Fatal(err)
			}
		} else if err != nil || string(got) != tc.input {
			t.Fatal(string(got), err)
		}
	}
}
func TestScannerLimits(t *testing.T) {
	input := "a\n" + strings.Repeat("x", 70_000) + "\nz\n"
	if got, err := scanLengths(input, 0); err == nil || !slices.Equal(got, []int{1}) {
		t.Fatal(got, err)
	}
	if got, err := scanLengths(input, 1<<20); err != nil || !slices.Equal(got, []int{1, 70_000, 1}) {
		t.Fatal(got, err)
	}
}
func TestSensitiveLog(t *testing.T) {
	var b bytes.Buffer
	newLogger(&b).Info("test", "token", Secret("sample-secret"))
	if strings.Contains(b.String(), "sample-secret") || !strings.Contains(b.String(), "[REDACTED]") {
		t.Fatal(b.String())
	}
}

type brokenWriter struct{}

func (brokenWriter) Write(p []byte) (int, error) { return len(p) - 1, nil }

type readerOnly struct{ io.Reader }

func TestCopyDetectsShortWrite(t *testing.T) {
	_, err := io.Copy(brokenWriter{}, readerOnly{strings.NewReader("data")})
	if !errors.Is(err, io.ErrShortWrite) {
		t.Fatal(err)
	}
}
func TestMaxBytesReader(t *testing.T) {
	request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("12345"))
	recorder := httptest.NewRecorder()
	limited := http.MaxBytesReader(recorder, request.Body, 4)
	defer limited.Close()
	_, err := io.ReadAll(limited)
	var tooLarge *http.MaxBytesError
	if !errors.As(err, &tooLarge) || tooLarge.Limit != 4 {
		t.Fatal(err)
	}
}

func TestDecodeOne(t *testing.T) {
	for _, tc := range []struct {
		input   string
		wantErr bool
	}{
		{`{"name":"Ada"} `, false},
		{`{"name":"Ada"} {"name":"Lin"}`, true},
		{`{"unknown":1}`, true},
		{`{"name":"Ada"} broken`, true},
	} {
		var user User
		err := DecodeOne(strings.NewReader(tc.input), &user)
		if (err != nil) != tc.wantErr {
			t.Fatalf("%q: %v", tc.input, err)
		}
	}
}

testdata/vetbad/main.go ​

go
package main

import (
	"context"
	"fmt"
	"log/slog"
	"sync"
	"time"
)

func main() {
	fmt.Printf("%d\n", "seven")
	var mu sync.Mutex
	other := mu
	_ = other
	ctx, _ := context.WithTimeout(context.Background(), time.Second)
	_ = ctx
	n := 66
	_ = string(n)
	slog.Info("accepted", "count")
	_ = time.Now().Format("2006-02-01")
}

testdata/exitdemo/main.go ​

go
package main

import (
	"fmt"
	"os"
)

func main() { defer fmt.Println("cleanup"); fmt.Println("working"); os.Exit(3) }

testdata/oldtimer/go.mod ​

go.mod
module example.com/oldtimer

go 1.22

testdata/oldtimer/main.go ​

go
package main

import (
	"fmt"
	"time"
)

func main() { t := time.NewTimer(time.Hour); defer t.Stop(); fmt.Println(cap(t.C)) }

testdata/removeddebug/go.mod ​

go.mod
module example.com/removeddebug

go 1.22

testdata/removeddebug/main.go ​

go
//go:debug asynctimerchan=1
package main

func main() {}

  1. Go 1.27.2 fmt:格式动词、输出函数、格式错误、Stringer 与递归注意事项、Errorf。 ↩︎ ↩︎ ↩︎ ↩︎

  2. Go 1.27.2 strings:查找切分、Trim cutset、Builder;固定源码。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  3. Go 1.27.2 bytes:Buffer、Bytes 有效期、Clone、NewReader。 ↩︎ ↩︎ ↩︎

  4. Go 1.27.2 sort:Slice、SliceStable、Search 与 SearchInts。 ↩︎ ↩︎ ↩︎

  5. Go 1.27.2 slices:排序比较契约、BinarySearch、Delete、Insert、Compact、Clone。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  6. Go 1.27.2 cmd/vet,以及本次 go tool vet help 列出的分析器。vet 是启发式静态检查,不能保证发现所有错误。 ↩︎ ↩︎ ↩︎

  7. Go 规范:String types、Conversions to and from a string type。 ↩︎ ↩︎

  8. Go 1.27.2 strconv:ParseInt、Atoi、ErrRange、NumError、FormatFloat、Quote。 ↩︎ ↩︎

  9. Go 1.27.2 time:Time 比较与单调时钟,Parse/ParseInLocation、布局、Duration、Timer、Ticker。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  10. Go 1.23 timer 变化;Go 1.27 Runtime 变化;GODEBUG 的 Go 1.27 历史。 ↩︎ ↩︎ ↩︎

  11. Go 1.27.2 runtime/runtime1.go、runtime/proc.go:已删除 GODEBUG 在进程启动时设为旧值的检查与 fatal 路径;已本地实际运行确认。 ↩︎

  12. Go 1.27.2 io:Reader/Writer 契约、EOF、ReadFull、ReadAll、Copy、LimitReader、MultiWriter、TeeReader。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  13. Go 1.27.2 net/http:Client、Timeout、Response.Body、ServeMux、Server、MaxBytesReader、MaxBytesError;httptest。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  14. Go 1.27.2 bufio:Flush、Scanner 限制、Err、Buffer、Bytes/Text;默认缓冲固定源码。 ↩︎ ↩︎ ↩︎ ↩︎

  15. Go 1.27.2 os:Open、OpenFile、Create、WriteFile、LookupEnv、Exit、IsNotExist、Root;io/fs 错误值。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  16. Go 1.27.2 log.Fatal。 ↩︎

  17. Go 1.27.2 encoding/json:Marshal、Unmarshal、Decoder、UseNumber、DisallowUnknownFields 与 v1/v2 迁移差异。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  18. Go 1.24 encoding/json 变化、os 变化。 ↩︎ ↩︎

  19. Go 1.27 JSON 发布说明;Go 1.25 实验性 JSON。正式 API 是否可导入已用本次主程序验证。 ↩︎ ↩︎

  20. Go 1.27.2 encoding/json/v2:默认选项、字段匹配、重复名字、nil 集合与 omitempty 语义。 ↩︎ ↩︎

  21. Go 1.22 ServeMux 变化。 ↩︎

  22. Go 1.27.2 context:取消函数、Done/Err、参数惯例、请求范围的 Value。 ↩︎ ↩︎

  23. Go 1.27.2 sync:各类型不可复制的条件、Pool 可清除条目、WaitGroup.Go、Once;Go 1.25 sync。 ↩︎ ↩︎ ↩︎

  24. Go 竞态检测器官方说明:只能检测实际运行路径中的竞态。 ↩︎

  25. Go 1.27.2 errors:Is、As、Join、AsType 和错误树遍历;Go 1.26 errors。 ↩︎ ↩︎ ↩︎ ↩︎

  26. Go 1.27.2 log/slog:HandlerOptions、级别、Attr、LogValuer、With、SetDefault、!BADKEY。 ↩︎ ↩︎ ↩︎

  27. Go 1.27.2 cmp:Compare 与 Or。 ↩︎

  28. Go 1.27.2 maps:Copy、Clone、Equal、EqualFunc、Keys/Values/All。 ↩︎ ↩︎ ↩︎

  29. Go FAQ:map 并发访问。 ↩︎

  30. Go 1.27.2 cmd/go:doc 与 test 子命令说明;默认 vet 检查另与同版本 go 命令源码交叉核对。 ↩︎ ↩︎

  31. Go 1.21 标准库变化、Go 1.23 迭代器;本次安装的官方工具链 api/go1.21.txt 至 api/go1.27.txt 可逐项核对 API 加入版本。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准